Skip to content

LSL Update#204

Merged
jdpigeon merged 29 commits into
mainfrom
device-lsl
Jul 6, 2026
Merged

LSL Update#204
jdpigeon merged 29 commits into
mainfrom
device-lsl

Conversation

@jdpigeon

@jdpigeon jdpigeon commented Apr 18, 2026

Copy link
Copy Markdown
Contributor

Summary

Adds Lab Streaming Layer (LSL) support to BrainWaves, enabling:

  • Interop with LabRecorder and any LSL-compatible acquisition/analysis tool
  • Connecting to arbitrary third-party LSL devices alongside Muse and Neurosity (new DEVICES.LSL option)
  • First-class Neurosity Crown support via @neurosity/sdk (previously Muse-only)
  • Synchronized stimulus markers pushed from lab.js experiments to the LSL network

Architecture

  • LSL runs in the main process (src/main/lsl/) using node-labstreaminglayer — native FFI can't run in sandboxed renderers
  • BLE acquisition stays in the renderer via Web Bluetooth (muse-js, @neurosity/sdk) — no noble/native BLE deps required
  • Shared types in src/shared/lslTypes.ts, aliased as @shared in both Vite blocks
  • Outlet manager pushes every connected device to LSL; inlet manager discovers/subscribes to external streams and forwards epochs to the renderer over IPC
  • Epoch batching in the renderer (~8–16 msg/sec per device) keeps IPC overhead negligible
  • We do not use Neurosity's device-side LSL — managing our own outlets keeps stream metadata consistent and avoids duplicates in LabRecorder

Test plan

  • - Connect a real Muse; confirm stream appears in LabRecorder with correct channel names and sample rate
  • - Run lab.js experiment; confirm markers are recorded synchronized with EEG
  • - Discover and subscribe to an external LSL stream; confirm live visualization
  • - Disconnect Muse mid-session; confirm toast + clean teardown
  • - npm run package on macOS arm64, macOS x64, Windows x64; confirm liblsl loads from the packaged build

@jdpigeon jdpigeon marked this pull request as ready for review April 18, 2026 21:35
@jdpigeon

Copy link
Copy Markdown
Contributor Author

Still need to test with an actual Muse and Crown, but things are looking pretty solid

@teonbrooks teonbrooks left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will give this a full pass at the beginning of next week. added some quick comments and a few questions.

I am liking how this is shaping up!

Comment thread docs/lsl-implementation-plan.md
Comment thread docs/lsl-implementation-plan.md
Comment thread docs/lsl-implementation-plan.md
Comment thread src/renderer/utils/eeg/muse.ts
jdpigeon and others added 23 commits June 22, 2026 10:23
Deletes cortex.js and emotiv.ts entirely. Removes all Emotiv branches
from device epics, experiment epics, pyodide epics, components, and
constants. DEVICES.EMOTIV, EMOTIV_CHANNELS, parseEmotivSignalQuality,
and Cortex credential env vars are all gone. Muse is now the only
supported device, laying the groundwork for LSL-based connectivity.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Electron 22+ no longer shows a native Bluetooth picker automatically.
Instead it fires select-bluetooth-device on webContents, requiring the
main process to call the callback with a deviceId. Without this handler
requestDevice() hung silently, leaving the search in a perpetual
SEARCHING state.

Changes:
- main/index.ts: register select-bluetooth-device handler that auto-selects
  the first Muse headset as BLE discovery progresses; add bluetooth:cancelSearch
  IPC handler so the renderer can reject a pending requestDevice() on timeout
- preload/index.ts: expose cancelBluetoothSearch() to renderer
- muse.ts: cache BluetoothDevice from getMuse() so connectToMuse() reuses
  it instead of firing a redundant requestDevice() call; add cancelMuseScan()
- deviceEpics.ts: call cancelMuseScan() in searchTimerEpic so the pending
  requestDevice() promise is cleaned up when the 3s search window expires
- docs/device-connectivity.md: full connectivity flow diagram and bug analysis

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
In React 18, setState is always batched — calling setState in an async
componentDidMount continuation schedules a re-render but does not
immediately commit the DOM change. The subsequent querySelector('webview')
therefore returned null, the dom-ready listener was never attached, and
subscribeToObservable was never called.

Fix: defer webview setup to componentDidUpdate, triggered when viewerUrl
transitions from empty to set. At that point React has already committed
the DOM update, so the webview element exists. Because componentDidUpdate
runs synchronously before the browser event loop can process the webview
load, the dom-ready listener is in place before it fires.

This fixes signal not flowing on the Explore EEG screen when navigating
to it while already connected.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds Lab Streaming Layer support so BrainWaves can publish EEG and
stimulus markers as LSL streams and ingest data from external LSL
devices. Enables LabRecorder integration and multi-device experiments.

Phase 1: Main-process LSLOutletManager + IPC bridge forwards batched
Muse EEG samples as an LSL outlet. Adds @shared alias, asarUnpack for
native bindings, and fixes MUSE_CHANNELS hardcoding in experimentEpics.

Phase 2: Neurosity Crown SDK support — getNeurosity/connectToNeurosity
mirror the Muse driver; deviceEpics route by deviceType.

Phase 3: LSLInletManager + UI to discover and connect to external LSL
streams. lslForwardEpic skips LSL inlet sources to avoid feedback loops.

Phase 4: RunComponent emits stimulus markers via sendLSLMarker alongside
the existing injectMuseMarker call, preserving the CSV-embedded marker
path used by the Pyodide analysis pipeline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Subscribes to lsl:status IPC at the App level and surfaces errors
via react-toastify. Completes Phase 5 production hardening (decimation,
BLE disconnect detection, error surfacing).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
node-labstreaminglayer 0.3.0 only ships an x86_64 liblsl.dylib in its
prebuild dir, which fails to load on arm64 Macs. patchDeps.mjs now
detects darwin-arm64 and symlinks the Homebrew-installed framework
binary over the bundled stub. No-op on x64 macs, Linux, and Windows.

Requires: brew install labstreaminglayer/tap/lsl

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Two related fixes that together unbreak Pyodide in production:

1. Protocol handler in main was looking at resources/webworker/src/ but
   electron-builder copies pyodide assets to resources/pyodide/. Update
   pyodideRoot to match the actual extraResources destination.

2. Worker was relying on import.meta.url to find pyodide.asm.wasm and
   python_stdlib.zip relative to pyodide.mjs. That works in dev (Vite
   middleware serves siblings from node_modules) but fails in prod where
   the bundled .mjs has no siblings. Set indexURL so pyodide fetches
   runtime files through the pyodide:// protocol handler — works in both.

Verified by installing the packaged dmg and running test plot.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
- Replace stale port-17173 http-server section with current
  pyodide:// protocol handler reality
- Document the prod resourcesPath/pyodide/ extraResources destination
- Add indexURL requirement for prod (siblings of pyodide.mjs aren't
  bundled, so import.meta.url resolution fails) — gotcha hit during
  packaging verification

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The .worktrees/modernization entry was an accidental submodule-style
gitlink (mode 160000) pointing into a local git worktree. There is no
.gitmodules and the worktree is not part of the repo tree, so it only
produced perpetual 'modified' noise in git status. Untrack it and
ignore .worktrees/ going forward.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
node-labstreaminglayer dlopen's liblsl at require time, so a static
import crashed the whole app on startup when liblsl was missing/
incompatible (e.g. Apple Silicon without the Homebrew build) — even for
Muse-only users who never need LSL.

Load the native bindings lazily and fail soft so LSL becomes a true
advanced, opt-in feature:
- src/main/lsl/native.ts: guarded require() in try/catch (memoized),
  exposing loadLSL() and isLSLAvailable()
- outlets.ts/inlets.ts: type-only imports + loadLSL() at call time;
  all ops no-op gracefully when liblsl is unavailable
- lsl:isAvailable IPC + preload bridge for renderer feature detection
- ConnectModal hides 'External LSL stream' when unavailable
- lslBridge no-ops sendEpoch/sendMarker when unavailable (no IPC spam)

Result: Muse/Neurosity work with zero LSL/LabRecorder dependency; LSL
features appear only where liblsl loads.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jdpigeon and others added 2 commits June 22, 2026 11:06
- behavior accuracy: count correct no-go/withhold trials (drop the
  response_given==='yes' filter from accuracy numerators; RT keeps it)
- balanceStimuliByCondition: iterate bucket keys so a stimulus with no
  condition no longer bails out and yields an empty experiment loop
- ExperimentCleanup: also reset topoPlot (stale map persisted across experiments)
- ParamSlider: map(Number) instead of map(parseInt) (index-as-radix -> NaN bounds)
- EEGViewer.findExtreme: fix nested slice that scanned the whole buffer
- multitasking: divide trial count by blocks.length*4 to stop ~2x over-generation
- MNE drop %: use drop_log_stats() so IGNORED/NO_DATA entries aren't counted

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
jdpigeon and others added 2 commits July 3, 2026 10:00
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jdpigeon

jdpigeon commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Discovered my Neurosity device (Notion v1) is likely too old to be worth supporting. Going to deprioritize Neurosity testing until we can get new hardware

jdpigeon and others added 2 commits July 6, 2026 13:53
…oneError

Three bugs on the collect→clean path, surfaced QAing the LSL branch with a
real Muse (all in the lab.js 23.x migration / async-boundary class):

- ExperimentWindow: read the datastore from `global.datastore`, not the
  removed `options.datastore`. The undefined path threw inside the lab.js
  `on('end')` handler, aborting the end sequence ("Error ending") and leaving
  appState.json half-written — which then bricked the workspace tile.
- HomeComponent: auto-trash unreadable workspaces (missing/corrupt
  appState.json) instead of the dead-end "please delete it" toast.
  deleteWorkspaceDir uses shell.trashItem, so it's recoverable.
- pyodideEpics loadEpochsEpic: mergeMap (not map) the async readFiles so the
  resolved CSV strings reach the worker; the unresolved Promise was hitting
  worker.postMessage and throwing DataCloneError.

Also documents the pre-existing Pyodide-worker RPC breakage in TODOS
(getEpochsInfoEpic relies on a request/response correlation layer a past
refactor deleted; identical on main, not LSL).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SNEh5EFg4dSpRrfJ2ae4vL
@jdpigeon jdpigeon merged commit 35039e8 into main Jul 6, 2026
10 checks passed
@jdpigeon jdpigeon deleted the device-lsl branch July 6, 2026 18:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants